SPB Git

Repository Public

SubmissionActions.tsx 7.8 KB 233 lines tsx

1"use client";2// Auteur : Simon-Pierre Boucher — contact@spboucher.ai3// Workflow d'une demande (client) : statut, priorité, assignation,4// confidentialité, tags, note interne, réponse au demandeur, archive/spam.5// Chaque action passe par l'API admin (revalidation RBAC serveur) puis6// rafraîchit la page serveur — pas d'état dupliqué.7import { useState } from "react";8import { useRouter } from "next/navigation";9import { PRIORITIES, CONFIDENTIALITY_LEVELS } from "@/lib/comms/categories";1011type Props = {12  id: number;13  statuses: Record<string, string>;14  status: string;15  priority: string;16  confidentiality: string;17  assignedTo: number | null;18  tags: string[];19  admins: { id: number; name: string }[];20  isArchived: boolean;21  isSpam: boolean;22  hasEmail: boolean;23};2425export default function SubmissionActions(p: Props) {26  const router = useRouter();27  const [busy, setBusy] = useState(false);28  const [error, setError] = useState("");29  const [note, setNote] = useState("");30  const [reply, setReply] = useState("");31  const [tagsText, setTagsText] = useState(p.tags.join(", "));32  const [sentInfo, setSentInfo] = useState("");3334  async function call(url: string, body: unknown): Promise<boolean> {35    setBusy(true);36    setError("");37    try {38      const res = await fetch(url, {39        method: url.includes("/notes") || url.includes("/reply") ? "POST" : "PATCH",40        headers: { "Content-Type": "application/json" },41        body: JSON.stringify(body),42      });43      const data = (await res.json().catch(() => ({}))) as { error?: string; sent?: boolean };44      if (!res.ok) {45        setError(data.error ?? "Action refusée.");46        setBusy(false);47        return false;48      }49      if (data.sent === false)50        setSentInfo("Message enregistré, mais l'envoi courriel a échoué — il reste dans la conversation.");51      router.refresh();52      setBusy(false);53      return true;54    } catch {55      setError("Réseau indisponible.");56      setBusy(false);57      return false;58    }59  }6061  const base = `/api/admin/submissions/${p.id}`;62  const sel = "field cursor-pointer !py-[8px] !text-[13px]";6364  return (65    <div className="flex flex-col gap-5">66      <div className="grid grid-cols-2 gap-3">67        <div>68          <label className="klabel mb-[4px] block">Statut</label>69          <select70            className={sel}71            value={p.status}72            disabled={busy}73            onChange={(e) => call(base, { status: e.target.value })}74          >75            {Object.entries(p.statuses).map(([k, v]) => (76              <option key={k} value={k}>{v}</option>77            ))}78          </select>79        </div>80        <div>81          <label className="klabel mb-[4px] block">Priorité</label>82          <select83            className={sel}84            value={p.priority}85            disabled={busy}86            onChange={(e) => call(base, { priority: e.target.value })}87          >88            {Object.entries(PRIORITIES).map(([k, v]) => (89              <option key={k} value={k}>{v}</option>90            ))}91          </select>92        </div>93        <div>94          <label className="klabel mb-[4px] block">Assignée à</label>95          <select96            className={sel}97            value={p.assignedTo ?? ""}98            disabled={busy}99            onChange={(e) =>100              call(base, { assigned_to: e.target.value ? Number(e.target.value) : null })101            }102          >103            <option value="">Personne</option>104            {p.admins.map((a) => (105              <option key={a.id} value={a.id}>{a.name}</option>106            ))}107          </select>108        </div>109        <div>110          <label className="klabel mb-[4px] block">Confidentialité</label>111          <select112            className={sel}113            value={p.confidentiality}114            disabled={busy}115            onChange={(e) => call(base, { confidentiality: e.target.value })}116          >117            {Object.entries(CONFIDENTIALITY_LEVELS).map(([k, v]) => (118              <option key={k} value={k}>{v}</option>119            ))}120          </select>121        </div>122      </div>123124      <div>125        <label className="klabel mb-[4px] block" htmlFor="w-tags">Tags (séparés par des virgules)</label>126        <div className="flex gap-2">127          <input128            id="w-tags"129            className="field !py-[8px] !text-[13px]"130            value={tagsText}131            onChange={(e) => setTagsText(e.target.value)}132            placeholder="ex. à-suivre, q1-2027"133          />134          <button135            className="btn btn-ghost !min-h-[38px] !px-4 !text-[12.5px]"136            disabled={busy}137            onClick={() =>138              call(base, { tags: tagsText.split(",").map((t) => t.trim()).filter(Boolean) })139            }140          >141            OK142          </button>143        </div>144      </div>145146      <div className="flex flex-wrap gap-2">147        <button148          className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]"149          disabled={busy}150          onClick={() => call(base, { is_archived: !p.isArchived })}151        >152          {p.isArchived ? "Désarchiver" : "Archiver"}153        </button>154        <button155          className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]"156          disabled={busy}157          onClick={() => call(base, { is_spam: !p.isSpam })}158        >159          {p.isSpam ? "Non-spam" : "Marquer spam"}160        </button>161        <button162          className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]"163          disabled={busy}164          onClick={() => call(base, { is_read: false })}165        >166          Marquer non lue167        </button>168      </div>169170      <div className="rule-ink pt-4">171        <label className="klabel mb-[4px] block" htmlFor="w-note">172          Note interne (jamais visible du demandeur)173        </label>174        <textarea175          id="w-note"176          rows={3}177          className="field resize-y !text-[13px]"178          value={note}179          onChange={(e) => setNote(e.target.value)}180        />181        <button182          className="btn btn-ghost mt-2 !min-h-[36px] !px-4 !text-[12.5px]"183          disabled={busy || !note.trim()}184          onClick={async () => {185            if (await call(`${base}/notes`, { body: note })) setNote("");186          }}187        >188          Ajouter la note189        </button>190      </div>191192      <div className="rule-ink pt-4">193        <label className="klabel mb-[4px] block" htmlFor="w-reply">194          Répondre au demandeur {p.hasEmail ? "(courriel via la conversation)" : ""}195        </label>196        {p.hasEmail ? (197          <>198            <textarea199              id="w-reply"200              rows={5}201              className="field resize-y !text-[13px]"202              value={reply}203              onChange={(e) => setReply(e.target.value)}204              placeholder="Votre réponse — elle est enregistrée dans la conversation et envoyée par courriel avec la référence."205            />206            <button207              className="btn btn-primary mt-2 !min-h-[38px] !px-5 !text-[13px]"208              disabled={busy || !reply.trim()}209              onClick={async () => {210                if (await call(`${base}/reply`, { body: reply })) setReply("");211              }}212            >213              Envoyer la réponse214            </button>215          </>216        ) : (217          <p className="gk-mono text-[11px] leading-relaxed text-ink-3">218            Soumission anonyme ou sans courriel — aucune réponse directe219            possible. Utilisez une note interne.220          </p>221        )}222      </div>223224      {sentInfo ? (225        <p className="gk-mono rounded-lg border-[1.5px] border-amber bg-amber-soft px-3 py-2 text-[11px] font-bold text-[#7a4d0d]">{sentInfo}</p>226      ) : null}227      {error ? (228        <p className="gk-mono rounded-lg border-[1.5px] border-danger bg-[var(--danger-soft)] px-3 py-2 text-[11px] font-bold text-danger">{error}</p>229      ) : null}230    </div>231  );232}233
Files main
Menu

Theme